feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler#2390
Conversation
…sion inside toNodeHandler
Routing on isLegacyRequest from a plain Node `(req, res)` handler required
hand-assembling a `globalThis.Request` from `req.headers` (header-array and
HTTP/2 pseudo-header pitfalls included), even though toNodeHandler already
contains the correct conversion as a private helper.
Export that helper as `toWebRequest(req, parsedBody?, options?)` from
`@modelcontextprotocol/node`. The function body is unchanged; toNodeHandler
now calls it with `{ signal }` in an options bag instead of a positional
AbortSignal, so the adapter's behavior is unchanged. Pass `parsedBody` when a
body parser already consumed the Node stream; `options.signal` ties the
constructed request to client disconnect the way toNodeHandler does.
Adds unit tests for both body paths (the parsedBody case asserts the Node
stream is never touched), header copying, the signal option, and the
clone-readability contract isLegacyRequest relies on. README + changeset.
isLegacyRequest(request) is the whole API: the body is read from an internal clone, so the caller's request stays readable for whichever handler it is routed to. That fact was the final paragraph of a long doc comment, after the routing semantics, which made the optional `parsedBody` read like a required companion. Move it into a lead paragraph: the single-argument form first, `parsedBody` as a perf escape hatch for a body the caller already holds parsed, and the one case it is genuinely needed (a Request whose own body was already read, where the internal clone would throw). Also point Node `(req, res)` callers at `toWebRequest(req)` — and `toWebRequest(req, req.body)` behind a body parser, since the Node stream is already drained there — from the companion `@modelcontextprotocol/node` change. Documentation only; no behavior change.
…hand-built globalThis.Request
The legacy-routing and elicitation servers each hand-read the Node body into
Buffer chunks, JSON.parsed it, and constructed a `globalThis.Request` from
`req.headers` (cast to `Record<string, string>`) just to call
isLegacyRequest — with a second argument the predicate does not need.
Use the exported `toWebRequest(req[, parsedBody])` from
`@modelcontextprotocol/node` instead, and call the predicate with just the
request. In legacy-routing (Express 5) the conversion is always handed a
parsed body (`req.body ?? {}` — body-parser leaves `req.body` undefined and
the stream unread for a non-JSON content type), so it never consumes a
stream the legacy transport still needs to read. In elicitation (plain
`node:http`) `toWebRequest` reads the stream once and both routing arms take
the body from the resulting `Request`; the predicate runs first, since it
classifies an internal clone and leaves the request readable.
No change to what either example demonstrates: for every input class the
same arm fires with the same body value as before.
🦋 Changeset detectedLatest commit: 1427667 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
… unparsed body
A `{}` probe classifies as an invalid JSON-RPC body (a reject), not as
legacy, so a POST that `express.json()` does not parse routes to the strict
modern arm — the previous comment claimed the legacy routing was preserved.
The code is unchanged: `?? {}` is still the right call, because the
alternative (no `parsedBody`) makes `toWebRequest` read the Node stream out
from under the legacy transport, which still needs it. Say what the line
does instead of what it preserves.
…ts docs
Drop the `?? {}`. It existed to keep `toWebRequest` from reading the Node
stream for the one input where Express leaves `req.body` undefined AND the
stream unread — a POST whose content type `express.json()` will not parse.
That is not valid MCP traffic, the example's client never sends it, and the
old code already routed it differently across Express majors (an unparsed
body is `{}` on Express 4 but `undefined` on Express 5), so there was no
stable behavior to preserve and no justification for a special case in the
canonical example.
`toWebRequest(req, req.body)` is the form the function's own docs and the
changeset teach; the example now matches them, and for everything
`express.json()` parses it routes exactly as before.
Keep what it is, where it came from, a two-line usage, and the one sharp edge (no `parsedBody` -> the Node stream is read to completion, so read the body from the returned `Request`, not from `req`; with `parsedBody` nothing is read). Drop the URL/header/method mechanics inventory, the entity-header rewrite detail (an implementation detail nobody programs against), the full Express route duplicated from examples/legacy-routing, and the essay on the `signal` option whose useful content is one line.
…equests An HTTP/2 request reaching a Node http2-compat handler carries its authority only as the `:authority` pseudo-header, with no `host` entry, so the conversion produced `http://localhost/<path>` with no Host header. Fall back to `:authority` the way Node's own `request.authority` getter does; HTTP/1.1 requests are unchanged (`host` wins). The HTTP/2 test previously supplied a `host` header alongside `:authority` — a combination real HTTP/2 clients don't send — so it passed without covering this. It now uses pseudo-headers only, plus a case pinning host-over- authority precedence.
There was a problem hiding this comment.
No bugs found in this revision — the earlier concerns (HTTP/2 :authority host derivation, the legacy-routing probe construction) are addressed in the current diff with test coverage. Deferring to a maintainer because exporting toWebRequest is a public API-surface decision (minor bump on @modelcontextprotocol/node) that warrants a human call under the repo's minimalism principle.
Extended reasoning...
Overview
This PR exports the previously-private nodeRequestToFetchRequest conversion as toWebRequest(req, parsedBody?, options?) from @modelcontextprotocol/node, switches toNodeHandler to call it (signal moved into an options bag — adapter behavior unchanged), rewrites the elicitation and legacy-routing examples to use it instead of hand-built globalThis.Request probes, rewrites the isLegacyRequest JSDoc to lead with the single-argument form, and adds a dedicated unit-test suite plus README/changeset entries.
Status of prior feedback
Both earlier inline comments are addressed in the current revision: the URL host now falls back to the :authority pseudo-header (with tests covering the no-host HTTP/2 case and the precedence case), and the legacy-routing example dropped the ?? {} probe in favor of plain toWebRequest(req, req.body), with the unparsed-body edge case acknowledged honestly in the PR description rather than claimed away. The bug-hunting system found no bugs in this revision, and I did not find new correctness issues: the toNodeHandler call site is behaviorally identical, the parsedBody re-serialization path and entity-header rewriting are unchanged code, and the clone-readability contract isLegacyRequest relies on is tested.
Security risks
Low. The conversion copies inbound headers and body into a web-standard Request; no auth, crypto, or permission logic is touched. The :authority fallback slightly changes which header feeds the constructed URL host for HTTP/2 requests, but host validation middleware still reads the Host header and fails closed, so this is a fidelity improvement rather than a validation-relevant change.
Level of scrutiny
Moderate. The runtime logic change is small (one host-derivation fallback, one signature refactor), but the PR adds a new export to a published package — a one-way door under this repo's "burden of proof is on addition" principle. The justification is reasonable (the conversion already exists internally, and the SDK's own examples were hand-rolling it), but whether toWebRequest belongs in the public surface vs. a cookbook is a maintainer decision, not one I should approve past.
Other factors
Test coverage for the new export is thorough (stream vs parsedBody body paths, multi-valued headers, pseudo-header skipping, signal option, clone readability), the changesets accurately describe the changes, and the example rewrites keep behavior for spec-valid traffic. Nothing here blocks the PR; it just needs a human call on the API addition.
Routing on
isLegacyRequestfrom a plain Node(req, res)handler currently means hand-building aglobalThis.Requestout ofreq.headers:That's a smell.
toNodeHandleralready contains the correct conversion (header arrays, HTTP/2pseudo-headers, the parsed-body re-serialization) as a private helper — users just can't reach it. It also
nudges people toward the two-argument
isLegacyRequest(probe, body), which reads as if the second argumentwere required. It isn't: the predicate clones internally, so
isLegacyRequest(request)is the whole API.Our own examples taught the same two-argument pattern, each behind ~16 lines of buffer-read +
JSON.parse+hand-built
Request.This exports the conversion as
toWebRequest()and moves both examples onto it. The Express one(
legacy-routing) becomes:and the plain
node:httpone (elicitation):How:
toWebRequest(req, parsedBody?, options?)is the existing private conversion, exported. The function bodyis unchanged, and
toNodeHandlernow calls it with the abort signal in the options bag instead of apositional argument, so the adapter's behavior is identical.
Promise<Request>(it reads the Node stream). PassparsedBodywhen a body parser alreadyconsumed the stream — it's re-serialized as the
Requestbody and the entity headers are rewritten. Passoptions.signalto tie the constructed request to client disconnect, the waytoNodeHandlerdoes.isLegacyRequest's docs now lead with the single-argument form and presentparsedBodyas the perfescape hatch it is, not a required companion.
Motivation and Context
isLegacyRequesttakes a web-standardRequest, but the Node hosting path only has anIncomingMessage.The conversion between the two already existed inside
toNodeHandler; exporting it removes the last reasonto build a
globalThis.Requestby hand — including in our own examples, which are what people copy.How Has This Been Tested?
New unit tests in
packages/middleware/node/test/toWebRequest.test.tscover the stream-read andparsedBodybody paths (the latter asserts the Node stream is never touched), the Host-derived URL, headercopying (multi-valued append, HTTP/2 pseudo-header skipping), the
signaloption, and the clone-readabilitycontract
isLegacyRequestrelies on. The existingtoNodeHandlersuite passes unchanged, which is theno-behavior-change check for the adapter path. The full example e2e suite passes (both rewritten stories
across all their transport × era legs), along with
typecheck:all, lint, and the docs build.Breaking Changes
None. New export;
toNodeHandlerbehavior is unchanged; theisLegacyRequestchange is documentation only;the examples route every input
express.json()parses identically.Types of changes
Checklist
Additional context
One edge worth naming: for a POST
express.json()doesn't parse (a non-JSON content type — not valid MCP),body-parser 2.x leaves
req.bodyundefined and the Node stream unread, sotoWebRequestreads it and thepredicate classifies the real bytes (the old hand-built, header-only probe never saw that body at all). Not
meaningful for spec traffic, and the old code already routed it differently between Express 4 and 5 (an
unparsed body is
{}on 4 butundefinedon 5, and the two classify differently).One deliberate nuance: the example's predicate now reads the body through the same
TextDecoderpath theentry itself uses, where the old hand-rolled read used
Buffer#toString('utf8'). The only observabledifference is a leading UTF-8 BOM, which the old read left in (so
JSON.parsefailed and the request fellto the legacy arm) and is now stripped, so the body classifies on its content.
The only other
Requestconstruction inexamples/isauthServer.ts'snew Request(new URL(req.originalUrl, issuer)), which is left alone on purpose: it synthesizes an OAuthdiscovery URL from
issuer, not a faithful conversion of the inbound request, sotoWebRequestwouldchange it.
@modelcontextprotocol/nodeREADME and changeset included.isLegacyRequest's rewritten docs are preciseabout the one case
parsedBodyis needed rather than just faster: aRequestwhose own body was alreadyread (the internal clone would throw). Behind a body parser the right fix is
toWebRequest(req, req.body)— the predicate still takes one argument.